feat: background workers = non-HTTP workers with shared state#2287
feat: background workers = non-HTTP workers with shared state#2287nicolas-grekas wants to merge 2 commits intophp:mainfrom
Conversation
e1655ab to
867e9b3
Compare
|
Interesting approach to parallelism, what would be a concrete use case for only letting information flow one way from the sidekick to the http workers? Usually the flow would be inverted, where a http worker offloads work to a pool of 'sidekick' workers and can optionally wait for a task to complete. |
da54ab8 to
a06ba36
Compare
|
Thank you for the contribution. Interesting idea, but I'm thinking we should merge the approach with #1883. The kind of worker is the same, how they are started is but a detail. @nicolas-grekas the Caddyfile setting should likely be per |
ad71bfe to
05e9702
Compare
|
@AlliBalliBaba The use case isn't task offloading (HTTP->worker), but out-of-band reconfigurability (environment->worker->HTTP). Sidekicks observe external systems (Redis Sentinel failover, secret rotation, feature flag changes, etc.) and publish updated configuration that HTTP workers pick up on their next request; with per-request consistency guaranteed via Task offloading (what you describe) is a valid and complementary pattern, but it solves a different problem. The non-HTTP worker foundation here could support both. @henderkes Agreed that the underlying non-HTTP worker type overlaps with #1883. The foundation (skip HTTP startup/shutdown, immediate readiness, cooperative shutdown) is the same. The difference is the API layer and the DX goals:
Happy to follow up with your proposals now that this is hopefully clarified. |
05e9702 to
8a56d4c
Compare
|
Great PR! Couldn't we create a single API that covers both use case? We try to keep the number of public symbols and config option as small as possible! |
Yes, that's why I'd like to unify the two API's and background implementations into one. Unfortunately the first task worker attempt didn't make it into |
|
The PHP-side API has been significantly reworked since the initial iteration: I replaced The old design used
Key improvements:
Other changes:
|
cb65f46 to
4dda455
Compare
|
Thanks @dunglas and @henderkes for the feedback. I share the goal of keeping the API surface minimal. Thinking about it more, the current API is actually quite small and already general:
The name "sidekick" works as a generic concept: a helper running alongside. The current set_vars/get_vars protocol covers the config-publishing use case. For task offloading (HTTP->worker) later, the same sidekick infrastructure could support:
Same worker type, same So the path would be:
The foundation (non-HTTP threads, cooperative shutdown, crash recovery, per-php_server scoping) is shared. Only the communication primitives differ. WDYT? |
b3734f5 to
ed79f46
Compare
|
|
|
Hmm, it seems they are on some versions, for example here: https://github.com/php/frankenphp/actions/runs/23192689128/job/67392820942?pr=2287#step:10:3614 For the cache, I'm not aware of a Github feature that allow to clear everything unfortunately 🙁 |
|
Note that I would love to have workers available for CLI scripts. Something that's entirely skipped for now. |
You're absolutely right. The point about the stream logic being quite low level for users still stands, though.
Should wait for that first. The current solution with an emulated cli through the embed sapi is a bit hacky and causes issues. I'd prefer not to add much to it until we get a proper cli sapi embedded. |
frankenphp.c
Outdated
| static bool bg_worker_validate_zval(zval *z) { | ||
| switch (Z_TYPE_P(z)) { | ||
| case IS_NULL: | ||
| case IS_FALSE: | ||
| case IS_TRUE: | ||
| case IS_LONG: | ||
| case IS_DOUBLE: | ||
| case IS_STRING: | ||
| return true; | ||
| case IS_OBJECT: | ||
| return (Z_OBJCE_P(z)->ce_flags & ZEND_ACC_ENUM) != 0; | ||
| case IS_ARRAY: { | ||
| zval *val; | ||
| ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(z), val) { | ||
| if (!bg_worker_validate_zval(val)) | ||
| return false; | ||
| } | ||
| ZEND_HASH_FOREACH_END(); | ||
| return true; | ||
| } | ||
| default: | ||
| return false; | ||
| } | ||
| } |
There was a problem hiding this comment.
theoretically, if validation fails, we could also fallback to serialization.
There was a problem hiding this comment.
That'd break SRP, I explained this above. Practically, that'd mean set_vars() could throw unattended exceptions. Better tell ppl they should split codepaths (serialize on their own or use a Symfony's DeepCloner or similar.)
|
after discussion with @nicolas-grekas today at symfony live, i tried out if we could offer a polyfill for this functionality. it is still very rough, but it shows the basic PoC by writing to a filesystem: https://github.com/dbu/shared-state-polyfill i'd be happy to evolve on it a bit, after getting input - though probably its best to wait until this PR is merged or at least the contract is sure to be final. (i don't want to hijack this thread, please open issues in my PoC repository to discuss the polyfill. @nicolas-grekas if you could review the basic thing before the frankenphp changes get merged, we can do a sanity check if we maybe should change something of the pattern to make it polyfill friendly. the notification stream is a bit funny for the polyfill as it would be unnecessary as each worker is its own process that receives the signals directly. and the name of the background worker being implicit makes polyfill a bit awkward, but is great for a smooth experience with frankenphp. |
| struct itimerspec its; | ||
| its.it_value.tv_sec = 0; | ||
| its.it_value.tv_nsec = 1; | ||
| its.it_interval.tv_sec = 0; | ||
| its.it_interval.tv_nsec = 0; | ||
| timer_settime(thread_php_timers[idx], 0, &its, NULL); |
There was a problem hiding this comment.
Not sure this is safe or a good idea. Would be better to store a reference to execution globals and do this instead:
zend_atomic_bool_store_ex(&EG(vm_interrupt), true);There might also be a way to access EG directly via TSRM, haven't looked into it too much yet though.
background_worker.go
Outdated
| func (registry *backgroundWorkerRegistry) Entrypoint() string { | ||
| return registry.entrypoint | ||
| } | ||
|
|
||
| func (registry *backgroundWorkerRegistry) Num() int { | ||
| if registry.num <= 0 { | ||
| return 0 | ||
| } | ||
| return registry.num | ||
| } | ||
|
|
||
| func (registry *backgroundWorkerRegistry) MaxThreads() int { | ||
| if registry.num > 0 { | ||
| return registry.num | ||
| } | ||
| return 1 | ||
| } | ||
|
|
||
| func (registry *backgroundWorkerRegistry) SetNum(num int) { | ||
| registry.num = num | ||
| } |
There was a problem hiding this comment.
I think some function here are unused
| num int // threads per background worker (0 = lazy-start with 1 thread) | ||
| maxWorkers int // max lazy-started instances (0 = unlimited) | ||
| autoStartNames []string // names to start at boot when num >= 1 |
There was a problem hiding this comment.
do you need these values here, aren't they already defined on the worker?
num 0 = lazy start
num 1 = autostart
| preparedEnvNeedsReplacement bool | ||
| logger *slog.Logger | ||
| requestOptions []frankenphp.RequestOption | ||
| backgroundScope string |
There was a problem hiding this comment.
The scope can also just be an integer. It should probably be requested from and managed by the frankenphp package.
|
Would be nice if someone with fresh eyes could look over this PR. Short summary from my side:
|
|
Another reason I dislike streams is that the polling api from (this RFC) might already be in PHP 8.6. |
|
Thanks for the review. Addressing each point: PR size / simplification: I removed dead methods, moved scope management to the frankenphp package ( API: Runtime worker starting: The main use case is libraries that ship their own background workers (e.g., a Redis Sentinel watcher package) without requiring users to list each one in the Caddyfile. The catch-all Cross-thread copying: Intentionally limited to scalars/arrays/enums - this enables immutable array zero-copy, interned string optimization, and the Stream-based shutdown: I explored signal-based shutdown extensively ( On the "clunky" concern, it's worth noting that the upcoming PHP 8.6 poll API RFC validates this direction: it introduces Example of forward compatibility - same code, both worlds: // Works today (PHP 8.2+)
$stream = frankenphp_worker_get_signaling_stream();
stream_select([$stream], [], [], 5);
$signal = fgets($stream);
// Works on PHP 8.6+ with zero changes to FrankenPHP
$poll = new Poll();
$poll->addReadable($stream, fn() => match (fgets($stream)) {
"stop\n" => false,
"task\n" => handleTask(), // anticipating from the task-based API in #2319
});
$poll->addTimer(5.0, fn() => frankenphp_worker_set_vars(refreshConfig()));
$poll->run();Same // Possible future API (PHP 8.6+, if SignalHandle lands)
$poll = new Poll();
$poll->addHandle(frankenphp_worker_get_poll_handle(), fn(string $signal) => match ($signal) {
'stop' => false,
'task' => handleTask(),
});
$poll->addTimer(5.0, fn() => frankenphp_worker_set_vars(refreshConfig()));
$poll->run();The stream version stays for BC, same underlying pipe, different wrapper. For simple cases today, the userland |
|
Protocol update: instead of using I'm now leaning toward renaming functions:
WDYT? |
|
Or possibly (suggested at SymfonyLive Paris), using
Use reactions to express your preference? (👎 on the previous to prefer the |
@AlliBalliBaba If anything that's a positive because it's built upon streams and will just make the userland handling cleaner (which is my main objection for the stream api).
Much better!
I like it better than get_signaling_stream. Especially because:
Is there actually a reason to not make this |
Renames: - frankenphp_worker_set_vars - frankenphp_set_worker_state - frankenphp_worker_get_vars - frankenphp_get_worker_state - frankenphp_worker_get_signaling_stream - frankenphp_get_worker_handle - Internal: varsPtr - statePtr, varsVersion - stateVersion, etc. Type widening: - set_worker_state accepts mixed (null, scalars, arrays, enums) - get_worker_state returns mixed - Non-array values are internally wrapped in a single-element array for uniform persistent storage, and unwrapped on read.
|
Thanks for the push in that direction :) @dunglas asked me why only arrays as "vars"? Suggesting we should accept strings and other scalars. This made me change the API this way. Still 3 functions:
About an HTTP-worker state, the purpose would be to expose a thread-safe in-memory KV store to sync them? |
Summary
Background workers are long-running PHP workers that run outside the HTTP cycle. They observe their environment (Redis, DB, filesystem, etc.) and publish configuration that HTTP workers read per-request - enabling real-time reconfiguration without restarts or polling.
PHP API
frankenphp_worker_set_vars(array $vars)- publishes config from a background worker (persistent memory, cross-thread). Skips all work when data is unchanged (===check).frankenphp_worker_get_vars(string|array $name, float $timeout = 30.0): array- reads config from HTTP workers (blocks until first publish, generational cache)frankenphp_worker_get_signaling_stream(): resource- returns a pipe-based stream forstream_select()integration. Closed on shutdown (EOF = stop).Caddyfile configuration
backgroundmarks a worker as non-HTTPnamespecifies an exact worker name; workers withoutnameare catch-all for lazy-started namesmax_threadson catch-all sets a safety cap for lazy-started instances (defaults to 16)max_consecutive_failuresdefaults to 6 (same as HTTP workers)max_execution_timeautomatically disabled for background workersphp_serverblock has its own isolated scope (managed byNextBackgroundWorkerScope())Shutdown
On restart/shutdown, the signaling stream is closed. Workers detect this via
fgets()returningfalse(EOF). Workers have a 5-second grace period.After the grace period, a best-effort force-kill is attempted:
max_execution_timetimer cross-thread viatimer_settime(EG(max_execution_timer_timer))CancelSynchronousIo+QueueUserAPCinterrupts blocking I/O and alertable waitsDuring the restart window,
get_varsreturns the last published data (stale but available). A warning is logged on crash.Forward compatibility
The signaling stream is forward-compatible with the PHP 8.6 poll API RFC.
Poll::addReadableaccepts stream resources directly - code written today withstream_selectwill work on 8.6 withPoll, no API change needed.Architecture
php_serverscope isolation with internal registry (unexported types, minimal public API viaNextBackgroundWorkerScope())backgroundWorkerThreadhandler implementingthreadHandlerinterface - decoupled from HTTP worker code pathsdrain()closes the signaling stream (EOF) for clean shutdown signalingpemalloc) withRWMutexfor safe cross-thread sharingset_varsskip: uses PHP's===(zend_is_identical) to detect unchanged data - skips validation, persistent copy, write lock, and version bumpget_varscalls return the same array instance (===is O(1))IS_ARRAY_IMMUTABLE)ZSTR_IS_INTERNED) - skip copy/free for shared memory strings$_SERVER['FRANKENPHP_WORKER_NAME']set for background workers$_SERVER['FRANKENPHP_WORKER_BACKGROUND']set for all workers (true/false)Example
Test coverage
17 unit tests + 1 Caddy integration test covering: basic vars, at-most-once start, validation, type support (enums, binary-safe strings), multiple background workers, multiple entrypoints, crash restart, signaling stream, worker restart lifecycle, non-background-worker error handling, identity detection, generational cache, named auto-start with m# prefix.
All tests pass on PHP 8.2, 8.3, 8.4, and 8.5 with
-race. Zero memory leaks on PHP debug builds.Documentation
Full docs at
docs/background-workers.md.